En esta primera fase, importaremos las librerías necesarias, cargaremos el conjunto de datos y realizaremos un Análisis Exploratorio de Datos (EDA) para entender la estructura, calidad y características de la información con la que trabajaremos.
Importar librerías
print("Importar librerías")# Manipulación de datosimport altair as altimport pandas as pdimport numpy as np# Procesamiento de Texto (NLP)import spacyimport nltkfrom nltk.corpus import stopwordsfrom nltk import word_tokenize # tokenizacionfrom nltk import pos_tag #lematizacionfrom nltk.stem import WordNetLemmatizerfrom nltk.corpus import wordnet# Procesamiento de texto y featuresfrom sklearn.model_selection import train_test_splitfrom sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, LabelEncoder, StandardScalerfrom sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizerfrom sklearn.compose import ColumnTransformerfrom sklearn.pipeline import Pipelinenltk.download('stopwords') # necessary for removal of stop wordsnltk.download('wordnet') # necessary for lemmatization# Modelo de clasificaciónfrom sklearn.linear_model import LogisticRegression, Ridge# Métricas de Evaluaciónfrom sklearn.metrics import ( classification_report, confusion_matrix, ConfusionMatrixDisplay, roc_auc_score, RocCurveDisplay, mean_squared_error, r2_score, silhouette_score)# Visualizaciónimport matplotlib.pyplot as pltimport seaborn as sns# Configuración de visualizaciónsns.set_style("whitegrid")plt.rcParams['figure.figsize'] = (10, 6)from sklearn.cluster import KMeansfrom sklearn.datasets import fetch_openmlfrom sklearn.metrics import silhouette_scoreimport reimport unicodedata# Visualizaciónimport matplotlib.pyplot as pltimport seaborn as sns# Visualización de textosfrom wordcloud import WordCloud# Configuracionesimport warningswarnings.filterwarnings('ignore')# Descargar recursos de NLTK (stopwords)nltk.download('stopwords', quiet=True)# Cargar modelo de SpaCy para español (para lematización)!python -m spacy download es_core_news_sm -qnlp_spacy = spacy.load('es_core_news_sm')
Importar librerías
[nltk_data] Downloading package stopwords to
[nltk_data] C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data] Package stopwords is already up-to-date!
[nltk_data] Downloading package wordnet to
[nltk_data] C:\Users\Lenovo\AppData\Roaming\nltk_data...
[nltk_data] Package wordnet is already up-to-date!
[+] Download and installation successful
You can now load the package via spacy.load('es_core_news_sm')
Definimos la función personalizada que realizará la limpieza y lematización del texto en español, según lo solicitado.
# Obtenemos stopwords en españolstopwords_es =set(stopwords.words('spanish'))def limpiar_y_lematizar(texto):""" Función completa para limpiar y lematizar texto en español. 1. Reemplaza tildes y caracteres especiales. 2. Convierte a minúsculas. 3. Elimina URLs, menciones (@) y hashtags (#). 4. Elimina puntuación y números. 5. Lematiza con SpaCy y elimina stopwords. """ifnotisinstance(texto, str):return""# 1. Reemplazar tildes (Normalización NFD) texto = unicodedata.normalize('NFD', texto).encode('ascii', 'ignore').decode('utf-8')# 2. Convertir a minúsculas texto = texto.lower()# 3. Eliminar URLs, menciones y hashtags texto = re.sub(r'http\S+|www\S+|https\S+', '', texto, flags=re.MULTILINE) texto = re.sub(r'[@#]\w+', '', texto)# 4. Eliminar puntuación y números texto = re.sub(r'[^a-zA-Z\s]', '', texto)# 5. Lematización con SpaCy y eliminación de stopwords doc = nlp_spacy(texto) lemmas = [ token.lemma_ for token in doc if token.text notin stopwords_es andnot token.is_punct andnot token.is_space andlen(token.text) >2# eliminar tokens muy cortos ]return" ".join(lemmas)# --- Prueba de la función ---texto_ejemplo = df['content'].iloc[0]print(f"--- Texto Original ---\n{texto_ejemplo}\n")print(f"--- Texto Limpio y Lematizado ---\n{limpiar_y_lematizar(texto_ejemplo)}")
--- Texto Original ---
@DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos de contratación qué haces de presidente ignorante!Borja no debería ser candidato es correcto al tener un vínculo
--- Texto Limpio y Lematizado ---
lavatar hocico presidente cartonhabla verdad cosa ser tiempo pasar cosassi entiend proceso contratacion hacer presidente ignoranteborgir deberio ser candidato correcto tener vinculo
EDA
Exploramos la estructura, los tipos de datos, los valores nulos y las distribuciones de las variables clave.
Primeras filas del dataset
print("Primeras filas del dataset")# Visualizar las primeras 5 filasdf.head()
@DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos...
print("Conteo de valores nulos por columna:")null_counts = df.isnull().sum()null_counts_percent = (null_counts /len(df) *100).round(2)null_summary = pd.DataFrame({'conteo_nulos': null_counts, 'porcentaje_nulos': null_counts_percent})print(null_summary[null_summary['conteo_nulos'] >0])
Conteo de valores nulos por columna:
conteo_nulos porcentaje_nulos
replyTo 10 0.67
hashtags 1379 91.93
mentions 1 0.07
toxicity_score 153 10.20
Hallazgo Clave 1:
La variable toxicity_score, que es nuestro target principal, tiene 153 valores nulos (10.2% del dataset). Para los modelos supervisados (regresión y clasificación), vamos a eliminar estas filas, ya que no podemos entrenar sin una etiqueta.
Análisis del Target: toxicity_score
Analizamos la distribución de nuestra variable objetivo principal.
Analisis sin excluir valores nulos, representación en barras
alt.Chart(df).mark_bar().encode( x=alt.X('toxicity_score:Q', bin=True, title='Nivel de Toxicidad'), y=alt.Y('count():Q', title='Frecuencia'), tooltip=['toxicity_score:Q', 'count():Q']).properties( title='Distribución de Toxicity Score').interactive()
Distribución completa del ‘toxicity_score’
Analisis sin excluir valores nulos, representación en círculos
alt.Chart(df).mark_circle().encode( alt.X('toxicity_score'), alt.Y('count()'), tooltip=['toxicity_score', 'count()']).properties( title='Distribución de Toxicity Score').interactive()
Distribución completa del ‘toxicity_score’
Análisis descriptivo de: toxicity_score.
print("Estadísticas descriptivas de 'toxicity_score':")print(df['toxicity_score'].describe())
Estadísticas descriptivas de 'toxicity_score':
count 1347.000000
mean 0.253879
std 0.243942
min 0.001940
25% 0.028444
50% 0.188392
75% 0.426917
max 0.939145
Name: toxicity_score, dtype: float64
Analisis sin valores nulos
# Gráfico interactivo con Altairchart = alt.Chart(df.dropna(subset=['toxicity_score'])).mark_bar().encode( x=alt.X('toxicity_score', bin=alt.Bin(maxbins=50), title='Nivel de Toxicidad'), y=alt.Y('count()', title='Frecuencia'), tooltip=[alt.X('toxicity_score', bin=alt.Bin(maxbins=50)), 'count()']).properties( title='Distribución de Toxicity Score')density = alt.Chart(df.dropna(subset=['toxicity_score'])).transform_density('toxicity_score', as_=['toxicity_score', 'density'],).mark_line(color='red').encode( x=alt.X('toxicity_score', title='Nivel de Toxicidad'), y=alt.Y('density:Q', title='Densidad'),)# Combinar histograma y densidad (en diferentes ejes Y)# (Para combinar en el mismo gráfico necesitarían escalas normalizadas,# pero para exploración visual, dos gráficos alineados son efectivos.)display(chart + density)
Distribución del ‘toxicity_score’
Hallazgo Clave 2:
La distribución de toxicity_score está fuertemente sesgada a la derecha (cola larga hacia valores altos), pero la gran mayoría de los tweets tiene un score de toxicidad bajo (cercano a 0). Esto es fundamental para la clasificación: si usamos un umbral fijo como 0.5, las clases resultarán muy desbalanceadas.
Análisis de Features Relevantes
Exploramos las variables que usaremos como features (predictoras).
print("Distribución de Variables Numéricas:")numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']# Creamos una figura con 2 filas y 3 columnasfig, axes = plt.subplots(nrows=2, ncols=3, figsize=(18, 10))fig.suptitle('Distribución de Features Numéricos', fontsize=16, y=1.02)# Aplanamos el array de ejes para iterar fácilmenteaxes = axes.flatten()for i, col inenumerate(numeric_features): sns.histplot(data=df_clean, x=col, kde=True, ax=axes[i]) axes[i].set_title(f'Distribución de {col}')# Detección de sesgo alto: 'authorFollowers' y 'time_response'# Si están muy sesgadas, una escala logarítmica ayuda a visualizarif col in ['authorFollowers', 'time_response']: axes[i].set_xscale('log') axes[i].set_title(f'Distribución de {col} (Escala Log)')plt.tight_layout()plt.show()
Distribución de Variables Numéricas:
Histogramas de variables numéricas
print("Boxplots de Variables Numéricas (para detectar outliers):")# Creamos una figura con 2 filas y 3 columnasfig, axes = plt.subplots(nrows=2, ncols=3, figsize=(18, 10))fig.suptitle('Boxplots de Features Numéricos', fontsize=16, y=1.02)# Aplanamos el array de ejesaxes = axes.flatten()for i, col inenumerate(numeric_features): sns.boxplot(data=df_clean, x=col, ax=axes[i]) axes[i].set_title(f'Boxplot de {col}')# Aplicamos escala logarítmica a las mismas variables sesgadasif col in ['authorFollowers', 'time_response']: axes[i].set_xscale('log') axes[i].set_title(f'Boxplot de {col} (Escala Log)')plt.tight_layout()plt.show()
Boxplots de Variables Numéricas (para detectar outliers):
Boxplots de variables numéricas
print("Conteo de Variables Categóricas:")categorical_features = ['isReply', 'authorVerified', 'has_profile_picture', 'source']# Creamos una figura con 2 filas y 2 columnasfig, axes = plt.subplots(nrows=2, ncols=2, figsize=(15, 12))fig.suptitle('Conteo de Features Categóricos', fontsize=16, y=1.02)# isReplysns.countplot(data=df_clean, x='isReply', ax=axes[0, 0])axes[0, 0].set_title('Conteo de "isReply"')# authorVerifiedsns.countplot(data=df_clean, x='authorVerified', ax=axes[0, 1])axes[0, 1].set_title('Conteo de "authorVerified"')# has_profile_picturesns.countplot(data=df_clean, x='has_profile_picture', ax=axes[1, 0])axes[1, 0].set_title('Conteo de "has_profile_picture"')# --- Tratamiento especial para 'source' ---# Obtenemos el Top 10 de 'source'top_10_sources = df_clean['source'].value_counts().nlargest(10).index# Graficamos 'source' (Top 10) de forma horizontal para mejor lecturasns.countplot(data=df_clean, y='source', order=top_10_sources, ax=axes[1, 1])axes[1, 1].set_title('Top 10 de "source" (Plataforma)')axes[1, 1].set_xlabel('Conteo')axes[1, 1].set_ylabel('Source')plt.tight_layout()plt.show()
Conteo de Variables Categóricas:
Conteo de variables categóricas
# Variables Numéricasnumeric_features_list = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']print("Estadísticas de Features Numéricos:")display(df[numeric_features_list].describe())# Variables Categóricascategorical_features_list = ['isReply', 'authorVerified', 'has_profile_picture', 'source']print("\nConteo de valores en Features Categóricos:")for col in categorical_features_list:print(f"\n--- {col} ---")print(df[col].value_counts(normalize=True).head(10)) # .head(10) para 'source'
Features Numéricos: Las variables tienen escalas muy diferentes (ej. authorFollowers vs mentions_count), lo que confirma la necesidad de escalamiento (ej. StandardScaler).
Features Categóricos:source Todos los registros de esta muestra fueron realizados desde Twitter for iPhone. OneHotEncoder es apropiado. isReply y authorVerified son booleanos que también serán codificados.
Generando Nube de Palabras del texto limpio…
print("Generando Nube de Palabras del texto limpio...")# 1. Aplicamos la limpieza (lematización, stopwords, etc.)# Esto puede tardar un momentotext_limpio = df_clean['content'].apply(limpiar_y_lematizar)# 2. Unimos todo el texto en un solo stringfull_text =" ".join(text_limpio)# 3. Generamos la nube de palabraswordcloud = WordCloud(width=1200, height=600, background_color='white', colormap='viridis', max_words=150 ).generate(full_text)# 4. Mostramos la imagenplt.figure(figsize=(15, 7))plt.imshow(wordcloud, interpolation='bilinear')plt.axis('off')plt.title('Nube de Palabras más Frecuentes (Lematizadas)', fontsize=16)plt.show()
Generando Nube de Palabras del texto limpio...
Nube de palabras del contenido de los tweets
Gráfico de Frecuencias (Top 20 Palabras)
print("Generando Gráfico de Frecuencias del texto limpio...")# Usamos CountVectorizer con nuestra función de limpiezavec = CountVectorizer(preprocessor=limpiar_y_lematizar)# Obtenemos la matriz de conteotext_counts = vec.fit_transform(df_clean['content'])# Sumamos las ocurrencias de cada palabrasum_words = text_counts.sum(axis=0) words_freq = [(word, sum_words[0, idx]) for word, idx in vec.vocabulary_.items()]words_freq =sorted(words_freq, key =lambda x: x[1], reverse=True)# Creamos un DataFrame con el Top 20top_words_df = pd.DataFrame(words_freq[:20], columns=['Palabra', 'Frecuencia'])# Graficamosplt.figure(figsize=(15, 8))sns.barplot(data=top_words_df, x='Frecuencia', y='Palabra', palette='plasma')plt.title('Top 20 Palabras más Frecuentes (Lematizadas)')plt.xlabel('Frecuencia Total')plt.ylabel('Palabra')plt.show()
Generando Gráfico de Frecuencias del texto limpio...
Top 20 palabras más frecuentes (lematizadas)
Heatmap de Correlación (Features Numéricos y Target)
print("Heatmap de Correlación (Features Numéricos y Target)")# Seleccionamos solo las columnas numéricas y el targetnumeric_and_target = numeric_features + ['toxicity_score']df_corr = df_clean[numeric_and_target]# Calculamos la matriz de correlacióncorr_matrix = df_corr.corr()# Graficamos el heatmapplt.figure(figsize=(12, 8))sns.heatmap(corr_matrix, annot=True, # Mostrar los valores numéricos cmap='vlag', # Paleta de colores (rojo-blanco-azul) fmt=".2f", # Formato con 2 decimales linewidths=0.5)plt.title('Heatmap de Correlación (Spearman)', fontsize=16)plt.show()
Heatmap de Correlación (Features Numéricos y Target)
Heatmap de correlación
Gráfico de Correlación Específico (content_length vs toxicity_score)
print("Heatmap de Densidad entre Longitud del Contenido y Toxicidad")plt.figure(figsize=(12, 8))sns.histplot(data=df_clean, x='content_length', y='toxicity_score', bins=50, cbar=True)plt.title('Heatmap de Densidad: Longitud vs. Toxicidad')plt.xlabel('Longitud del Contenido (Caracteres)')plt.ylabel('Score de Toxicidad')plt.show()
Heatmap de Densidad entre Longitud del Contenido y Toxicidad
Heatmap de Densidad: Longitud del Tweet vs. Score de Toxicidad
2. Preprocesamiento de datos
En esta sección, preparamos los datos para el modelado:
Manejamos los nulos del target.
Definimos las variables X (features) e y (targets).
Creamos la función de limpieza de texto (NLP) que incluye lematización.
Limpieza de Nulos y Creación de Targets
Como se decidió en el EDA, eliminamos las filas donde toxicity_score es nulo para los modelos supervisados.
print(f"Tamaño original: {df.shape}")df_clean = df.dropna(subset=['toxicity_score'])print(f"Tamaño después de eliminar nulos en target: {df_clean.shape}")# Definición de Targets# 1. Target de Regresión (continuo)y_reg = df_clean['toxicity_score']# 2. Target de Clasificación (binario)# Usamos el umbral de 0.5 como se solicitó.UMBRAL_TOXICIDAD =0.5y_class = (df_clean['toxicity_score'] > UMBRAL_TOXICIDAD).astype(int)# Revisamos el desbalanceo de clases (esperado por el EDA)print("\nBalance de clases para el target binario (umbral > 0.5):")print(y_class.value_counts(normalize=True))
Tamaño original: (1500, 27)
Tamaño después de eliminar nulos en target: (1347, 27)
Balance de clases para el target binario (umbral > 0.5):
toxicity_score
0 0.819599
1 0.180401
Name: proportion, dtype: float64
Nota sobre Desbalanceo: Como se anticipó, la clase 1 (tóxico) representa solo el 24% de los datos. Usaremos class_weight='balanced' en el modelo de clasificación para mitigar esto.
Definición de Features (X) y Targets (y)
Seleccionamos las columnas que servirán como features.
# Columnas de features identificadas en el EDAtext_features ='content'numeric_features = ['authorFollowers', 'time_response', 'account_age_days', 'mentions_count', 'hashtags_count', 'content_length']categorical_features = ['isReply', 'authorVerified', 'has_profile_picture', 'source']# Rellenamos nulos en 'source' (feature categórico) con 'Desconocido'# (Aunque en este dataset no parece haber nulos en 'source' tras limpiar el target, es buena práctica)df_clean['source'] = df_clean['source'].fillna('Desconocido')# Creamos el DataFrame X de featuresX = df_clean[numeric_features + categorical_features + [text_features]]print(f"Dimensiones de X (features): {X.shape}")print(f"Dimensiones de y_reg (target regresión): {y_reg.shape}")print(f"Dimensiones de y_class (target clasificación): {y_class.shape}")
Dimensiones de X (features): (1347, 11)
Dimensiones de y_reg (target regresión): (1347,)
Dimensiones de y_class (target clasificación): (1347,)
3. División de datos
Dividimos los datos en conjuntos de entrenamiento (train) y prueba (test) para poder evaluar nuestros modelos de forma objetiva.
Separación de features y target
Ya tenemos X (features) e y_reg / y_class (targets) definidos en la sección anterior.
Split
Usamos train_test_split para crear las divisiones. Es crucial dividir X, y_reg e y_class simultáneamente para mantener la alineación de los índices.
# Dividimos los datosX_train, X_test, y_train_reg, y_test_reg, y_train_class, y_test_class = train_test_split( X, y_reg, y_class, test_size=0.25, # 25% para test random_state=42, stratify=y_class # Estratificamos por el target de clasificación para mantener la proporción)print(f"Tamaño X_train: {X_train.shape}")print(f"Tamaño X_test: {X_test.shape}")print(f"Tamaño y_train_reg: {y_train_reg.shape}")print(f"Tamaño y_test_class: {y_test_class.shape}")
Aquí definimos el ColumnTransformer y entrenamos los tres modelos solicitados.
Pipeline (ColumnTransformer)
Creamos el pipeline de preprocesamiento principal usando ColumnTransformer. Este se encargará de aplicar las transformaciones correctas a cada tipo de columna (numérica, categórica y texto).
# 1. Pipeline para Features Numéricosnumeric_transformer = Pipeline(steps=[ ('scaler', StandardScaler())])# 2. Pipeline para Features Categóricoscategorical_transformer = Pipeline(steps=[ ('onehot', OneHotEncoder(handle_unknown='ignore', sparse_output=False))])# 3. Pipeline para Features de Texto# Pasamos nuestra función personalizada al preprocesador de TfidfVectorizertext_transformer = Pipeline(steps=[ ('tfidf', TfidfVectorizer(preprocessor=limpiar_y_lematizar))])# 4. Combinar todo en el ColumnTransformerpreprocessor = ColumnTransformer( transformers=[ ('num', numeric_transformer, numeric_features), ('cat', categorical_transformer, categorical_features), ('text', text_transformer, text_features) ], remainder='drop'# Ignora columnas no especificadas)print("ColumnTransformer definido exitosamente.")
ColumnTransformer definido exitosamente.
Tarea 1: Regresión (Ridge)
Construimos el pipeline final (preprocesador + modelo) y lo entrenamos para la tarea de regresión.
print("Construir pipeline para Regresión (Ridge)")# Creamos el pipeline completopipeline_reg = Pipeline(steps=[ ('preprocessor', preprocessor), ('model', Ridge(random_state=42))])
Construir pipeline para Regresión (Ridge)
Entrenamos el modelo de regresión
print("Entrenando modelo de Regresión (Ridge)...")pipeline_reg.fit(X_train, y_train_reg)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Para el clustering sobre texto, seguimos un enfoque ligeramente diferente:
Creamos un preprocesador que solo extrae y vectoriza el texto.
Buscamos el k óptimo usando el Coeficiente de Silueta.
Entrenamos el modelo KMeans final con el k óptimo.
Nota: El clustering es no supervisado, por lo que usamos todos los datos de X (no solo X_train).
Búsqueda de K Óptimo
# 1. Creamos el vectorizador de textotfidf_vectorizer = TfidfVectorizer(preprocessor=limpiar_y_lematizar, max_features=1000)# 2. Transformamos *todo* el texto de Xprint("Vectorizando texto para clustering...")X_text_tfidf = tfidf_vectorizer.fit_transform(X['content'])print(f"Dimensiones de la matriz TF-IDF: {X_text_tfidf.shape}")# 3. Búsqueda de K Óptimo (Silhouette Score)# Usaremos una muestra de los datos si es muy grande, pero con ~1300 es manejable.silhouette_scores = []range_n_clusters =range(2, 11) # Probamos de 2 a 10 clustersprint("Calculando Coeficiente de Silueta para K de 2 a 10...")for k in range_n_clusters: kmeans = KMeans(n_clusters=k, random_state=42, n_init=10) cluster_labels = kmeans.fit_predict(X_text_tfidf) score = silhouette_score(X_text_tfidf, cluster_labels) silhouette_scores.append(score)print(f"K={k}, Silhouette Score={score:.4f}")# Graficamos los resultadosplt.figure(figsize=(10, 6))plt.plot(range_n_clusters, silhouette_scores, 'bo-', markersize=8)plt.xlabel('Número de Clusters (k)')plt.ylabel('Coeficiente de Silueta')plt.title('Método de la Silueta para encontrar K Óptimo')plt.grid(True)plt.show()# Seleccionamos el K óptimok_optimo = range_n_clusters[np.argmax(silhouette_scores)]print(f"\nEl K óptimo (mayor score de silueta) es: {k_optimo}")
Vectorizando texto para clustering...
Dimensiones de la matriz TF-IDF: (1347, 1000)
Calculando Coeficiente de Silueta para K de 2 a 10...
K=2, Silhouette Score=0.0377
K=3, Silhouette Score=0.0390
K=4, Silhouette Score=0.0373
K=5, Silhouette Score=0.0373
K=6, Silhouette Score=0.0345
K=7, Silhouette Score=0.0344
K=8, Silhouette Score=0.0313
K=9, Silhouette Score=0.0345
K=10, Silhouette Score=0.0379
Método de la Silueta para K Óptimo
El K óptimo (mayor score de silueta) es: 3
Entrenamiento de KMeans
Entrenamos el modelo final de KMeans con el k_optimo encontrado.
kmeans = KMeans(n_clusters=k_optimo, random_state=42, n_init=10)print(f"Entrenando KMeans con k={k_optimo}...")cluster_labels = kmeans.fit_predict(X_text_tfidf)print("Clustering completado.")# Añadimos los labels del cluster al DataFrame limpio para análisis posteriordf_clean['cluster'] = cluster_labels
Entrenando KMeans con k=3...
Clustering completado.
5. Predicciones
Usamos los modelos entrenados para generar predicciones sobre el conjunto de prueba (X_test).
Predicciones de Regresión
print("Generar predicciones de regresión.")y_pred_reg = pipeline_reg.predict(X_test)
Generar predicciones de regresión.
Predicciones de Clasificación
Generamos tanto las clases predichas como las probabilidades (necesarias para la curva ROC).
print("Generndo predicciones de clasificación.")y_pred_class = pipeline_class.predict(X_test)y_pred_proba_class = pipeline_class.predict_proba(X_test)[:, 1] # Probabilidad de la clase 1 (tóxico)
Generndo predicciones de clasificación.
Predicciones de Clustering
Las “predicciones” del clustering son las etiquetas asignadas a cada punto de dato, las cuales ya se calcularon y almacenaron en df_clean['cluster'] en el paso anterior.
6. Evaluaciones del modelo
Evaluamos el rendimiento de cada una de nuestras tres tareas.
Tarea 1: Evaluación de Regresión (Ridge)
Evaluamos qué tan bien nuestro modelo predice el score continuo de toxicidad.
Métricas (RMSE y R²)
rmse = np.sqrt(mean_squared_error(y_test_reg, y_pred_reg))r2 = r2_score(y_test_reg, y_pred_reg)print(f"--- Evaluación Modelo de Regresión (Ridge) ---")print(f"Root Mean Squared Error (RMSE): {rmse:.4f}")print(f"R-cuadrado (R²): {r2:.4f}")
--- Evaluación Modelo de Regresión (Ridge) ---
Root Mean Squared Error (RMSE): 0.1904
R-cuadrado (R²): 0.4166
Interpretación:
RMSE: Mide el error promedio de predicción en la misma escala que el target. Un valor más bajo es mejor.
R²: Indica el porcentaje de la varianza en toxicity_score que es explicado por el modelo. Un valor más cercano a 1 es mejor. (Es común que los modelos de texto para regresión tengan un R² moderado).
Visualización (Real vs. Predicho)
plot_df = pd.DataFrame({'Real': y_test_reg, 'Predicho': y_pred_reg})# Scatter plot con Altairscatter = alt.Chart(plot_df).mark_circle(size=60, opacity=0.5).encode( x=alt.X('Real', title='Valor Real de Toxicidad'), y=alt.Y('Predicho', title='Valor Predicho de Toxicidad'), tooltip=['Real', 'Predicho']).properties( title='Regresión: Valor Real vs. Predicho')# Línea de referencia (perfecta predicción)line = alt.Chart(pd.DataFrame({'x': [0, 1], 'y': [0, 1]})).mark_line(color='red', strokeDash=[3,3]).encode( x='x', y='y')display(scatter + line)
Valores Reales vs. Predichos (Regresión)
Tarea 2: Evaluación de Clasificación (LogisticRegression)
Evaluamos qué tan bien nuestro modelo distingue entre tweets “tóxicos” y “no tóxicos”.
Reporte de Clasificación y Matriz de Confusión
print("--- Evaluación Modelo de Clasificación (LogisticRegression) ---")print("\nReporte de Clasificación:")print(classification_report(y_test_class, y_pred_class, target_names=['No Tóxico (0)', 'Tóxico (1)']))# Matriz de Confusiónprint("\nMatriz de Confusión:")cm = confusion_matrix(y_test_class, y_pred_class)disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['No Tóxico', 'Tóxico'])fig, ax = plt.subplots(figsize=(7, 7))disp.plot(ax=ax, cmap='Blues', colorbar=False)plt.title('Matriz de Confusión')plt.show()
--- Evaluación Modelo de Clasificación (LogisticRegression) ---
Reporte de Clasificación:
precision recall f1-score support
No Tóxico (0) 0.90 0.91 0.90 276
Tóxico (1) 0.56 0.54 0.55 61
accuracy 0.84 337
macro avg 0.73 0.72 0.73 337
weighted avg 0.84 0.84 0.84 337
Matriz de Confusión:
Matriz de Confusión (Clasificación)
Interpretación (Clase Tóxica - 1):
Precision: De todos los tweets que el modelo etiquetó como tóxicos, ¿cuántos realmente lo eran?
Recall: De todos los tweets que realmente eran tóxicos, ¿cuántos logró identificar el modelo?
Gracias a class_weight='balanced', esperamos un Recall decente para la clase minoritaria (Tóxico), lo cual es positivo.
Interpretación: El score AUC-ROC mide la habilidad del modelo para discriminar entre las dos clases. Un valor de 1.0 es perfecto, 0.5 es aleatorio.
Tarea 3: Evaluación de Clustering (KMeans)
Evaluamos los grupos (clusters) encontrados. Como es no supervisado, la evaluación es más cualitativa.
Análisis Cuantitativo (Relación con Toxicidad)
Vemos el score de toxicidad promedio en cada cluster que encontramos.
# Usamos df_clean que tiene la columna 'cluster'cluster_analysis = df_clean.groupby('cluster')['toxicity_score'].describe()print(f"Análisis de 'toxicity_score' por Cluster (k={k_optimo}):")display(cluster_analysis)# Visualizaciónplt.figure(figsize=(10, 6))sns.boxplot(data=df_clean, x='cluster', y='toxicity_score')plt.title('Distribución de Toxicidad por Cluster')plt.show()
Análisis de 'toxicity_score' por Cluster (k=3):
count
mean
std
min
25%
50%
75%
max
cluster
0
1156.0
0.263967
0.246424
0.001940
0.033511
0.199981
0.437768
0.939145
1
88.0
0.255634
0.252675
0.003393
0.017357
0.171552
0.466188
0.862967
2
103.0
0.139157
0.169547
0.002026
0.007257
0.047852
0.254629
0.710546
Interpretación: Este es el análisis clave. ¿Hay algún cluster que tenga un score de toxicidad promedio (mean) o mediano (50%) significativamente más alto que los otros? Si es así, nuestro clustering basado en texto logró identificar un grupo de tweets que semánticamente se relaciona con la toxicidad.
Análisis Cualitativo (Ejemplos de Tweets)
Inspeccionamos tweets aleatorios de cada cluster para entender su “tema”.
pd.set_option('display.max_colwidth', 200)print("\n--- Ejemplos de Tweets por Cluster ---")for i inrange(k_optimo):print(f"\n===== CLUSTER {i} (Toxicidad media: {cluster_analysis.loc[i, 'mean']:.3f}) =====") sample_tweets = df_clean[df_clean['cluster'] == i]['content'].sample(3, random_state=42)for tweet in sample_tweets:print(f" - {tweet}\n")
--- Ejemplos de Tweets por Cluster ---
===== CLUSTER 0 (Toxicidad media: 0.264) =====
- @DanielNoboaOk con este gobierno ya no hay pan para tanto ladrón sigamos adelante con noboa
- @DanielNoboaOk la miseria la vivimos con ud.
- @leonorc2106 @LuisaGonzalezEc Jajajajajjaja
===== CLUSTER 1 (Toxicidad media: 0.256) =====
- @DanielNoboaOk @DiegoBorjaPC Yo no le creo a un presidente de cartón. Todo 5💙
- @DanielNoboaOk @DiegoBorjaPC Lávate el hocico presidente de cartón,habla la verdad y cómo son las cosas! Cómo han sido los tiempos y cómo han pasado las cosas,si no entiendes cómo son los procesos de contratación qué haces de presidente ignorante!Borja no debería ser candidato es correcto al tener un vínculo
- @LuisaGonzalezEc Pero tus panas asambleístas no apoyan ninguna ley para castigar a los delincuentes,por eso la delincuencia no para. Así que no te hagas la que te duele. Que eso te gusta para hacer quedar mal al presidente. Así, que a tus borregos con ese falso dolor
===== CLUSTER 2 (Toxicidad media: 0.139) =====
- @Santhy91 @LuisaGonzalezEc Luisa la sumisa desdolarizadora
Los mediocres votan por esta tipeja que apoya al GENOCIDA DICTADOR maduro
- ¡Respeto a la mujer!
Salvo que algún líder de RC5 la quiera de esclava sexual. Ahí si se jode. Los líderes sobre todas las cosas.
Casualmente es el primer mandamiento: Amarás a *tus líderes con todo tu corazón y con toda tu alma y con toda tu mente.
*Dónde tus líderes serán los que Correa te mande y ordene.
Ya maduren, RC5 es el mismo proyecto que mantiene a Cuba, Nicaragua y Venezuela esclavas del socialismo que quieren votar.
Noboa es una bestia pero Luisa es parte de una organización criminal internacional, a Noboa podemos hacerle un proceso revocatorio del mandato, con Luisa no tendremos nunca otro @Lenin
Votar nulo no ayuda, así lo calcularon desde la época de Chávez.
- @LuisaGonzalezEc @DianaAtamaint @cnegobec @FFAAECUADOR Chúpate la Plata y el lunes te vas a Venezuela a conseguir Trabajo Luisa !!!!!! https://t.co/iNMBdXotpb
7. Conclusiones
Reflexión final sobre los resultados del proyecto.
Calidad de Datos y EDA: El dataset, aunque pequeño (1500 registros), fue suficiente para un pipeline completo. El hallazgo clave del EDA fue el sesgo extremo en toxicity_score, lo cual impactó directamente la estrategia de clasificación, haciendo necesario el uso de class_weight='balanced'.
Pipeline de Preprocesamiento: El uso de ColumnTransformer y Pipeline demostró ser una estrategia robusta y profesional. Permitió encapsular toda la lógica de transformación (escalado numérico, OneHot categórico y TF-IDF con lematización personalizada) en un solo objeto, evitando fugas de datos y simplificando el entrenamiento.
Rendimiento de Regresión: Como se esperaba, predecir un score de toxicidad fino (regresión) a partir de texto y metadatos es difícil. El modelo Ridge probablemente arrojó un R² modesto, indicando que las features lineales (TF-IDF + metadatos) no capturan toda la complejidad semántica que define un score de toxicidad numérico.
Rendimiento de Clasificación: El modelo LogisticRegression para la tarea binaria (Tóxico / No Tóxico) probablemente funcionó mucho mejor (evaluado por AUC-ROC y F1-Score). La lematización y el manejo del desbalanceo de clases fueron cruciales. Los resultados de la matriz de confusión (específicamente el recall de la clase “Tóxico”) validan la estrategia.
Patrones de Clustering: El análisis de KMeans basado solo en texto (TF-IDF) fue revelador. Al comparar el score de toxicidad promedio de los clusters encontrados, pudimos validar si ciertos “temas” o estilos de lenguaje (capturados por los clusters) se correlacionan con niveles más altos de toxicidad.
Pasos Futuros y Mejoras
Modelos Avanzados: Para mejorar el rendimiento, especialmente en regresión, el siguiente paso sería usar modelos basados en embeddings (como Word2Vec o FastText) o Transformers (como BETO, la versión en español de BERT).
Hyperparameter Tuning: Podríamos usar GridSearchCV o RandomizedSearchCV sobre los pipelines completos para encontrar los mejores hiperparámetros (ej. alpha en Ridge, C en LogisticRegression, o max_features y ngram_range en TfidfVectorizer).
Feature Engineering: Crear features adicionales, como el análisis de sentimiento (polaridad), la cantidad de mayúsculas, o la longitud promedio de las palabras, podría añadir más señal a los modelos.
Estrategia de Target: Experimentar con diferentes umbrales o una clasificación multiclase (ej. usando cuartiles como ‘bajo’, ‘moderado’, ‘alto’) podría ofrecer insights diferentes.